Skip to content

✨ Add xmd prompt: turn a request into an approved executable Plan (#260) - #657

Merged
taras merged 15 commits into
mainfrom
agent/issue-260-prompt-command
Aug 30, 2026
Merged

✨ Add xmd prompt: turn a request into an approved executable Plan (#260)#657
taras merged 15 commits into
mainfrom
agent/issue-260-prompt-command

Conversation

@taras

@taras taras commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Closes #260.

Why

xmd run -e executes a program you already wrote. xmd prompt is the step before that: you describe the outcome, and a readable Executable Markdown workflow turns it into a Plan — a document that states what you asked for in ordinary language and places the components that do the work alongside those words.

xmd prompt "ask me for my age and write it to a file"

The point is that the authorship policy is not in TypeScript. What the agent is asked, how many times a draft may be repaired, what you are shown and what happens when you approve nothing all live in one packaged Markdown document you can read and argue with.

What changes

Before:

$ xmd prompt "ask me for my age and write it to a file"
error: unrecognized command

After — the approved Plan is the result, printed to stdout by default:

$ xmd prompt "ask me for my age and write it to a file"
# Ask for and save your age

Ask me for my age.

<Elicit as="answer" schema={{ type: "object", properties: { age: { type: "number" } } }} />

Write it to a file.

<File path="age.txt">{answer.age}</File>

Four delivery modes, exactly one per invocation: source on stdout (default); --output <path> exclusively creates a file; --run executes the Plan; --output --run writes the file, then runs it.

How it works

Every invocation executes one root document — the packaged prompt command document — and a second only under --run:

fixed preflight → syntax catalog → prompt profile
  → packaged command document: draft → check → repair ×3 → review ×10
  → complete teardown → final admission
  → deliver exactly one of: stdout · --output file · --run · --output then --run

The prompt profile is a constrained host context: a per-session working directory derived as sha256(session name), proven empty before the provider is built, no additional directories, no MCP servers, no native tools, strict denial. Permission flags configure the approved Plan, not authorship, and cannot widen it.

A draft is inert text throughout. It is checked without being executed, and only after approval, complete teardown and a second host validation can those bytes be delivered or run.

Review guide

Start with: packages/cli/src/documents/prompt-command.md — the whole authorship policy, in the open.

Then review:

  1. packages/cli/src/prompt-profile.ts — the ceiling, the session directory lifecycle, and the host-declared <CheckDraft>.
  2. packages/cli/src/prompt.ts — teardown, final admission, and the four delivery modes.
  3. packages/cli/src/prompt-args.ts — fixed grammar, option-signature freezing, run-only flag refusal.
  4. packages/cli/src/packaged-document.ts + scripts/build-npm.ts — how the document reaches every build.

Look carefully at:

  • <Return> selects a value; it does not end the body. Approval and exhaustion are the two arms of one <If>/<Else> for exactly this reason — an earlier version returned the draft and then hit a later <Fail>, so every approval failed.
  • Directory cleanup has two outcomes and no third. Empty → removed non-recursively. Non-empty, or vanished (ENOENT) → preserved untouched, command fails terminally. Nothing is ever recursively deleted, and named sessions are exempt because continuation depends on their identity.
  • Draft failures versus caller failures. A draft failure is the agent's and earns a repair turn; a caller failure raises out of <CheckDraft> and ends the command. Getting this backwards teaches the agent about mistakes it cannot fix.

What must stay true

  • Authorship policy stays in Markdown. No retry, review, approval, revision, abort or exhaustion logic in TypeScript, <Prompt> or the Agent API — enforced by the packaged document owning every branch, checked by assertions that count the requirement block three times and pin the introduction whole.
  • The approved bytes are the agent's close value, unmodified — no fence stripping, no extraction. Checked by fencing a draft containing a five-backtick run and asserting byte identity through review, --output and execution.
  • Only a complete successful turn becomes a draft. Failed, cancelled or protocol-invalid turns terminate before review — a host admission invariant the document cannot weaken.
  • Authorship never journals. A journal exists only once --run begins.
  • The packaged document is found the same way in every build — resolved from import.meta.url, never cwd, never the component search path.

How to verify it

deno task test packages/cli/tests/prompt-args.test.ts packages/cli/tests/packaged-document.test.ts packages/cli/tests/prompt-command-document.test.ts packages/cli/tests/prompt.test.ts packages/cli/tests/prompt-cli.test.ts
deno task test --changed=origin/main

At 142272da: focused suites 5 passed (40 steps); typecheck, lint, format and git diff --check clean. The affected graph passed at 1e449879 with 830 passed (5556 steps), 0 failed, and nothing executable has changed since — the four commits after it touch prose, tests and CLI grammar only.

Portability is proven per build, because a document read at runtime fails differently in each: the loader suite runs under Deno, Node and Bun; scripts/tests/cli-npm-bin.test.ts builds the real npm package and asserts esm/src/documents/prompt-command.md is byte-identical; scripts/tests/packaged-document.test.ts holds both deno compile --include sites to the documents that exist; and the compiled dist/xmd was run by absolute path from an empty temp cwd with HOME redirected, reaching an ACP probe — which is only possible after reading the embedded document and starting a turn.

Several regressions were confirmed to fail without their fix, since one that passes either way proves nothing: removing the cleanup ensure, replacing rmdir with a recursive remove, reverting ENOENT to a silent return, and retargeting the <CheckDraft> teardown to a nonexistent file all turn their case red.

Scope

Included

  • the xmd prompt command, its grammar, help and four delivery modes;
  • the packaged prompt command document and its <prompt-command> execution;
  • the prompt profile, its per-session directory lifecycle and constrained provider;
  • the host-declared <CheckDraft> capability and the post-teardown admission gate;
  • packaged-asset transport across source, npm and compiled builds;
  • specs/prompt-command-spec.md plus updates to four existing specs and architecture.md.

Intentionally unchanged

  • <Prompt> is still exactly one Agent turn. Nothing in core changed to accommodate this command.
  • No public <Plan>, custom command documents, --plan/--plan-eval or File/Glob authorship context — those are 🏛️ Compose Plan authorship through a reusable <Plan> component #660.
  • No --save; it was unreleased and is refused by name rather than aliased.
  • No stdin or editor input, and no automatic revision after a runtime failure.

New abstractions

  • prompt-args.ts — the request/property boundary is decidable from fixed grammar alone, and proving every refusal is effect-free needs a pure function over argv.
  • agent-stack.tsrun and prompt take the same Agent options and must not reach different conclusions from one command line.
  • prompt-profile.ts — the constrained context and the trusted checker declared into it.
  • packaged-document.ts — two functions locating a shipped document from its own module URL.
  • report.ts — both commands end in one ordinary execution and should report failure identically.
  • Each has concrete consumers; no speculative functionality.

Risks and limitations

  • One ordering is structural, not test-pinned. Registering the directory release before mkdir cannot be discriminated by a black-box test — a default session's establishment cannot fail after mkdir without adding a production seam, which was deliberately not added. Everything downstream is covered.
  • --session continuation is proven against a shared in-memory ACPX store, not a live provider.
  • The ten-round exhaustion <Fail> after the loop is a defensive fallback and says so; exhaustion is decided inside review.
  • xmd prompt must be the first argument, as xmd workflow must, with an explicit refusal otherwise.
  • xmd run's Agent flag errors surface marginally earlier now, since both commands share one settled AgentStack.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 12 redundant comments. Inline suggestions to remove them below.


// The trusted host selects its own root provider by name. Document-level
// selection goes through the installation protocol; this is the host saying
// what it configured, which no document is composing around.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// what it configured, which no document is composing around.

yield* installPermissionMode(permissionMode);
// `xmd run` is the one command that has a terminal to give away. Help,
// document inspection and `xmd test` install no launcher, so a document that
// reaches <Session.Launch> under any of them refuses instead of spawning.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// reaches <Session.Launch> under any of them refuses instead of spawning.

Comment thread packages/cli/src/cli.ts
},
// Declared so `xmd run --help` lists it with every other option. The value is
// lifted out of argv by readEvalFlags before parsing — see eval-source.ts —
// so this field is never the source of the document.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// so this field is never the source of the document.

Comment thread packages/cli/src/cli.ts
// The same object generation was configured from. The provider it
// installs is a fresh ordinary one — the generator's scope is already
// gone — but which agent it defaults to and what it may do were
// decided once, for the whole invocation.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// decided once, for the whole invocation.

Comment thread packages/cli/src/cli.ts
// Refused here rather than with the other commands' inline refusal below,
// because that one is reached through the parse this branch exists to skip.
// An inline document is what `xmd prompt` sets out to write, so a caller who
// supplied one would otherwise watch it generate a different one instead.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// supplied one would otherwise watch it generate a different one instead.

Comment thread packages/cli/src/prompt.ts Outdated
yield* deps.installElicitation();
// The same vocabulary the catalog just described. Validation and the
// catalog read one registry, so a component the generator was told about
// is one validation resolves.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// is one validation resolves.

*installElicitation() {
yield* Elicitation.around(
{
// deno-lint-ignore require-yield

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// deno-lint-ignore require-yield

{ at: "min" },
);
},
// deno-lint-ignore require-yield

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// deno-lint-ignore require-yield

return yield* scoped(function* () {
yield* ensure(() => rm(dir, { recursive: true, force: true }));
yield* API.Env.around({
// deno-lint-ignore require-yield

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// deno-lint-ignore require-yield

});
// The provider `API.Files` has no host default for, installed exactly where
// the runtime entrypoint installs it: a document that reaches the
// filesystem must reach the caller's, or fail.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// filesystem must reach the caller's, or fail.

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown

PR #657: ✨ Add xmd prompt: turn a request into an approved executable Plan (#260)

29 files, +6386 / -156

Scope

🔴 PR has 6542 lines changed. Split into focused PRs.

🟡 6542 lines changed. PRs under 400 receive more thorough review.

🟡 29 files changed. Are all changes related?

🟡 Changes span 10 directories.

🟡 PR mixes config and source changes.

Structural

Oxlint structural signals:

  • no-unnecessary-type-arguments ×10: packages/cli/src/props.ts
  • no-unused-vars ×2: packages/cli/src/cli.ts
  • no-empty-function ×1: packages/cli/src/cli.ts
  • no-unnecessary-type-assertion ×1: packages/cli/tests/support/fake-acp.ts

Slop

  • packages/cli/src/cli.ts:1264 (removed)
  • packages/cli/src/cli.ts:1905 (removed)
  • packages/cli/src/cli.ts:238// so this field is never the source of the document.
  • packages/cli/src/cli.ts:956// decided once, for the whole invocation.
  • packages/cli/src/prompt-args.ts:217// there is no alias to keep.
  • packages/cli/src/prompt.ts:207// last.
  • scripts/build-npm.ts:337// on Node and Bun only.

Oxlint slop signals:

  • no-console ×4: scripts/build-npm.ts, packages/cli/src/cli.ts

Static Analysis

Oxlint: 32 diagnostics across 7 files (13 rules)
Density: 0.005 violations/added-line

no-unnecessary-type-arguments (10): packages/cli/src/props.ts
no-console (4): scripts/build-npm.ts, packages/cli/src/cli.ts
unbound-method (4): packages/cli/tests/support/prompt-harness.ts, packages/cli/src/prompt.ts, packages/cli/tests/support/fake-acp.ts
no-useless-spread (3): packages/cli/tests/support/fake-acp.ts
no-unused-vars (2): packages/cli/src/cli.ts
no-unsafe-type-assertion (2): packages/cli/src/prompt.ts, packages/cli/src/cli.ts
consistent-function-scoping (1): packages/cli/src/prompt-profile.ts
no-empty-function (1): packages/cli/src/cli.ts
no-array-sort (1): packages/cli/src/props.ts
no-base-to-string (1): packages/cli/src/prompt-profile.ts
no-unnecessary-type-assertion (1): packages/cli/tests/support/fake-acp.ts
no-floating-promises (1): packages/cli/src/cli.ts
consistent-return (1): packages/cli/src/cli.ts

Correctness

No extraneous code patterns detected.

taras added 12 commits August 30, 2026 15:02
`xmd prompt "<request>"` asks the configured ACP agent for a complete
executable Markdown root, validates it without executing any of it, repairs its
definite defects by asking again, shows a person the exact bytes, and runs the
approved ones through the ordinary supplied-source path under the `<prompt>`
identity. It adds authorship around one normal document; it adds no second
execution model, props model or journal.

Authorship sits outside durability. The catalog, the fresh generator session,
every repair and the review create no journal and replay nothing, and the
generator's scope closes before the optional exclusive `--save` and before
execution. A defect the draft authored is repairable and earns one of three
turns; a defect the command line authored terminates without spending a turn on
something the agent cannot fix.

`xmd run` and `xmd prompt` now share one execution field set, one resolved Agent
configuration, and one props source resolution.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6
Three review findings on the xmd prompt command.

An inline document reached the generator. `preparePropsPhase` skips the parse
that carries the other commands' `-e` refusal, so a supplied document was
dropped in silence and the command generated a different one. Refused in
prompt's own branch, before the catalog, the provider, the review, the save,
the journal or any execution.

The agent configuration was resolved twice, once for generation and again for
the document, so one command line had two chances to read DEFAULT_AGENT_NAME
differently. A dispatch now settles it once and hands the resolved value to
both consumers; DocumentMode carries the settled stack rather than the flags.
The executed program still gets a fresh ordinary provider and inherits neither
the generator session nor its system prompt.

A document failing its own <Testing> boundary printed a bare message where a
run prints a `tests failed:` heading. reportFailure moves to a module both
commands share.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6
The first document-backed command needs its Markdown where the command is: a
source checkout, a published npm package, and a compiled binary with no
checkout. The document is the deployed program, so it ships as a file rather
than as a generated string.

src/documents/prompt-plan.md is that program. A package declares what it ships
by putting it in that directory; test documents and scenario fixtures live under
src/ too, and a build that swept those up would publish fixtures and grow every
binary carrying them.

The command locates it from its module's own URL, never from the working
directory and never through the component search path, so it finds the same
policy whatever directory a person stands in and no repository file can answer
for it.

Each build keeps the directory beside its module: both deno compile sites embed
it, and the npm build copies it into the emitted tree, because dnt emits the
module graph only and an asset nothing imports would otherwise reach Node and
Bun missing while Deno stayed green. A regression holds the two compile sites to
the documents that exist, since that list is the one thing no build discovers.

The host still runs the TypeScript authorship loop; wiring the plan program into
the plan profile follows.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6
A <Return> selects the value a value body publishes; it does not end that body,
and a later failure wins over a value already selected. The approve branch
returned the candidate and broke out of the review loop, then execution left the
Session and reached the unconditional exhaustion <Fail>. Every approval settled
as the ten-draft failure and no approved candidate could reach the host.

Approval now binds the exact candidate to a null-sentinel result, and one
exhaustive branch after the Session either returns it or fails. Only the arm
taken runs, so an approved candidate has no later sibling failure to overtake
it. Bounds, prompt wording, diagnostics, presentation, Session structure and the
authored abort remain as they were.

The review schema also needed an explicit string type: Ajv strict mode refuses
minLength on an untyped property, so every review was refused before a person
saw anything.

Diagnostics reach their code block through <Json> rather than a JSON.stringify
call in an expression, so serializing them is a document construct like
everything else this policy does.

The regression runs the packaged document itself, resolved against first-party
declarations with an empty include list, against a scripted agent turn, a
scripted approval and a test validator. It fails with the exhaustion message
before this change.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6
`xmd prompt` now executes two roots with a scope boundary between them. First
`src/documents/prompt-command.md`, the checked-in first-party Markdown that
implements the conversion and its review workflow; then the Plan it returned,
through the ordinary supplied-source path. The TypeScript authorship loop —
`author()`, `evaluate()` and `review()` — is gone: what the assistant is asked,
how many drafts may be fixed, how many you are shown and what happens when you
approve nothing are readable Markdown now, and arguable.

A Plan is not a script with comments. The shipped generation, repair and
revision instructions ask for every requested outcome as reader-facing prose
with each component placed immediately after the sentences describing what it
does, so the source can be audited and the execution followed by the same
narrative. That authorship rule is the document's and human review's; it is
never a hidden validation rule.

The prompt profile is what that document runs inside. The assistant gets an
empty directory of this host's own, no additional directories, no MCP servers,
no native tools and a strict denial answered inside the provider — so nothing
composed around it can widen a ceiling with nothing in it, and `--approve-all`
configures the approved Plan rather than the conversation that wrote it. The
document itself reaches no Files, command, service or network capability, runs
against no repository component search, and cannot opt out of a failing turn
ending it: a turn that streamed half a program and failed presents nothing.

`<ValidateCandidate>` is declared to that execution rather than resolved from a
repository, and it keeps the two kinds of failure apart. A defect a draft
authored comes back as structured facts the document may repair; a defect the
command line authored raises out of it, so no policy can catch a caller's
mistake and call it feedback.

The host takes the returned Plan back as untrusted text. Only after every Prompt
task, provider and Elicitation resource has torn down does it validate those
exact bytes again and resolve their props — a revision that changed a property's
declared type changes what the run receives — and only then does `--save` create
the file and the Plan run.

`--session <name>` names the assistant session; without it each invocation
places its own. The profile's working directory is one fixed host-owned path
because a session's key includes the directory it lives in, and a location that
changed every time would leave that option unable to name anything.

Evidence runs the packaged document itself against a scripted agent and a
scripted review: the shipped words reach the turn, ten presentations bound the
review with no revision on the tenth, an interleaved Plan returns byte for byte,
and what a person reads says each thing once however many rounds it took.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6
…on (#260)

Four corrections to the prompt profile and the command document it runs.

One directory per logical session, at
`~/.xmd/prompt/sessions/<sha256(logical-session-name)>`, replaces the single
`~/.xmd/prompt` every invocation shared. A shared directory was one ambient
location two conversations could both stand in; a fresh one each time would have
left `--session` unable to name anything, since a session's key includes the
directory it lives in. The digest gives both: a generated name reaches a
location nothing else does, and the same explicit name reaches the same one, so
the provider continues the session it established rather than placing a second.
The leaf is the digest and never the name — a caller's string that becomes a
path is a caller's string that can escape one.

Emptiness is now proven rather than assumed. The directory is created empty and
required to be empty before the provider is constructed or a session is
materialized, and anything already in there is a terminal refusal naming the
path. Nothing is deleted: what is in a directory this host did not authorize
anything to write to is not this command's to clean up.

Stopping on the tenth draft says two different things, and now the document says
both. When that draft still has problems, `abort` is the only choice offered and
there was never a Plan to approve, so it routes to the authored exhaustion
message. Every other abort — including one on a tenth draft that could have been
approved — stays the ordinary ending. The branch after the Session remains an
exhaustive fallback.

Final admission is now shown to be independently effective. A scripted draft
uses a repository component that exists while the command document runs and is
removed as its scope tears down; the unchanged approved bytes then fail the
host's second validation, in that order, with no save, journal or execution.
Both validations run the production path — leaving the component in place makes
the case pass and execute, which is what makes it evidence.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6
…ry back (#260)

Two ownership corrections: one about which directories a test may touch, and one
about which directories an invocation may keep.

The suite no longer reads, creates or removes anything under a real
`~/.xmd/prompt`. Snapshotting that root and deleting whatever appeared after the
snapshot was unsafe on its face — absence from a snapshot says nothing about who
created a directory, so an overlapping case or a real invocation could have its
state removed by a test that never owned it. Where profile session directories
live is now a host dependency: production keeps its default, and a harness names
a tree it made itself, passed as a value with no flag, environment variable,
document prop or replaceable context anywhere near it. `createPromptHarness`
requires that root, so no case can fall back to the host's, and each root is a
per-scope temporary directory removed whole — which is safe precisely because
the scope created everything in it. The one case that needs two invocations to
share a root says so explicitly.

Only an explicitly named session needs its directory afterwards. Its identity is
what a later `--session` derives the same ACPX session from, so it stays, is
required empty on the way in every time, and is never cleaned. An
invocation-unique default names nothing anybody can ask for again, so its
directory belongs to the command: after the command document and every provider,
Prompt task and Elicitation resource inside it has torn down, exactly one
cleanup is attempted, and it settles before final admission, the save or the
execution begins. Still empty, and the leaf is removed non-recursively. No
longer empty, and the directory and its contents are preserved and the command
fails terminally — this host authorized nothing to write there, and deleting a
stranger's files to get on with the work is the opposite of what a ceiling is
for. Being an `ensure` is what makes an abort, a failed turn and a cancellation
settle the same way a success does.

Which of the two applies is a trusted host value — whether the caller wrote
`--session` — never inferred from the shape of the generated name, and never
visible to the command document.

Both outcomes are held by evidence that fails without them: disabling the
cleanup leaves the leaf behind on success and before admission, and making the
removal recursive turns the preserved-and-refused case into a silent success.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6
… making it (#260)

Rebased onto `<Json as>` (#667), which supplies what the single-child `<Let>`
wrapper was standing in for. Both wrappers in the packaged prompt command
document are gone; `<Json value={check.diagnostics} as="problems" />` binds the
serialized text directly, and the bytes reaching `<CodeBlock>` are the same
bytes — the repair turn and the review presentation still carry the complete
structured problems, now proven by parsing the fence back rather than by
matching a substring of it.

The default session's directory is now a scope-owned resource rather than a
directory with an ensure attached afterwards. The release is registered before
the `mkdir` that could create it, so there is no window in which a leaf exists
that nothing is responsible for: a failure between making it and using it hands
it back like every other ending. What the release knows is whether the
directory was ever handed over, and that decides which question it is answering.

Once established, the exit has exactly two outcomes and no third. Still the
empty directory it was given, and the leaf is removed, non-recursively. Anything
else — content that appeared, or a directory that vanished — is preserved as
found and fails the command terminally. A leaf that disappears under a live
conversation was silently accepted before, which was wrong twice over: nothing
here is allowed to remove it, so its absence is interference, and treating
interference as a clean exit would let admission, the save and the execution
proceed on a conversation something else had already reached into.

A directory establishment never handed over is a different question, already
answered by what establishment reported. The release leaves that report and the
directory's contents alone, while still handing back an empty leaf it did
create.

Explicitly named directories are untouched by any of this: they stay, because
the next `--session` derives the same ACPX session identity from where they are.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6
… end (#260)

The command document is now a workflow somebody can read straight through. It
opens by saying what `xmd prompt` is for — a Prompt describes steps, the
components carry them out, and the coding agent turns both into one document
that explains and executes the sequence — and every stage after that is a
heading: creating the first draft, checking and repairing it, reviewing it,
continuing from the decision, returning the approved Plan.

Every Plan now begins with one descriptive level-one title, and the generation,
repair and revision turns each ask for it alongside the steps-beside-components
structure, so a replacement cannot quietly drop either. The worked example is a
titled Plan itself. This is an authorship and human-review requirement, not a
rule hidden in the checker: `<CheckDraft>` reports structural facts and would
accept a titleless Plan that a person would send back.

`<ValidateCandidate>` is `<CheckDraft>` throughout. It checks a draft rather than
validating a candidate, and nothing about what it does changed.

The review choices are the words a person reads — Approve, Request changes,
Stop — with no internal spelling behind them. A tenth draft that still has
problems now offers something better than only stopping: Explain what went
wrong makes one more ordinary turn in the same Session, carrying only the final
diagnostics, because the conversation already holds the Prompt, the catalog and
every draft. It asks for an explanation and explicitly not another Plan; its
answer is inert text, reopens no draft limit, is reported as the coding agent's
own words, and ends the command. So the host's instruction layer no longer
demands that every answer be document source — it says only that an answer
belongs to the message that asked for it, and each authored turn owns its own
shape.

The approved Plan is the command's result. By default it goes to stdout, byte
for byte, with nothing this command added, so it can be piped, diffed or read
before anybody commits to it. `--output` puts those bytes in a file instead,
exclusively created. `--run` runs it. With both, the file is written first and
only a successful write is followed by the run. `--save` is gone rather than
aliased; nothing has been released to keep compatible with.

That makes most execution flags conditional, so they are refused rather than
ignored: a caller who asks for a journal, a permission mode or an exec deadline
from a command that runs nothing has not been answered. `--include`,
`--agent-provider`, `--default-agent`, `--session` and `--timeout` keep working
always — they build the catalog, settle the agent, name the conversation, admit
properties and bound the command. A journal exists only when `--run` begins.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6
)

A repair or a revision was told to "keep" the draft's title. That is the wrong
instruction for a replacement: a draft whose title is missing, in the wrong
place, or does not describe the Plan has nothing worth keeping, and an
instruction to keep it asks the coding agent to carry the defect forward. Each
Plan-producing turn — the first draft, each repair, each revision — now states
the complete requirement on its own: optional frontmatter and then one
descriptive level-one heading as the first body content, the Prompt's complete
sequence as readable steps, every requested outcome, a meaningful order, and each
component beside the prose describing what it does. Replacements are told to
write the title the Plan needs rather than the one the last draft had.

None of that reaches `<CheckDraft>`, `validateDocument()` or TypeScript. It is
what the workflow asks for and what a person reviews, which is where a judgement
about whether a title describes a Plan belongs.

The prose under "Continue from your decision" claimed a final invalid draft left
only one choice. It leaves two: ask the coding agent what went wrong, or stop.

The governing documents still described the earlier command. They were swept
deliberately rather than by replacing words: the opening example is a titled
Plan, the flow shows the four destinations, and the failure table, timeout
paragraph, acceptance rows, terminology rows, construct inventory, executable-MDX
identity section, ACP lifecycle paragraph and root-props admission paragraph no
longer say that an approved Plan always runs, that `--save` is the output option,
that stopping is called abort, that admission is followed unconditionally by
execution, or that every success writes a journal.

The evidence follows the same rule. The approved introduction is pinned whole,
punctuation included, rather than sampled; the requirement block is asserted
against the actual initial, repair and revision turns and counted three times in
the shipped source; and the run-only preflight table covers every spelling,
including `-V` and `--secret-detection`. The test-local `validateCandidate` is
`checkDraft`, so a terminology sweep finds no survivor of the old name.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6
The introduction claimed to be the approved wording and was one byte away from
it: `coding agent's plan` with an ASCII apostrophe where the contract has U+2019.
The regression that was supposed to catch that had copied the same byte, so it
pinned the file to itself rather than the file to the contract. Both now carry
E2 80 99, and the fixture's comment says why it is the byte and not the sentence
that is being held.

Two contract passages still described the earlier command. The ACP lifecycle
said the profile's scope closes before "the save and the execution"; it closes
before final admission and before whichever result was asked for. The command
spec said `xmd prompt` executes two root documents; it always executes the
command document and executes a second only under `--run`.

The terminology sweep reached the comments, messages and test names that the
earlier passes left behind: "the save" in the deadline and teardown comments, the
harness's `--save`, the profile's two directory failures now saying "nothing was
output or run", and the review-decision comments and test names that still called
stopping an abort. The unrelated abort in `runDocument` is left alone — it is
about a document's own failure, not this command's.

`--save` had no real proof it was gone. The case that passed it also passed two
mutually exclusive permission flags, so the earlier failure decided the outcome
and the assertion would have held whether or not the option still worked. That
case no longer carries it, and a focused one does — on a command line that is
otherwise entirely valid.

Making that case honest exposed something worth fixing. `--save out.md` was
refused, but as "unrecognized argument: out.md": the scanner passed the unknown
option to a parser that stops at the first option it does not define and drops
the rest, so what a caller heard about was the value rather than the flag. Fixed
preflight now refuses an option this command does not define, by name, and names
`--output` when the option was `--save`.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6
The module contract opened by claiming `xmd prompt` owns two root document
executions, and its flow diagram ended by running the second one unconditionally
— one line after the line listing stdout, an `--output` file, a run, or both as
the four ways the bytes are delivered. A reader following the diagram would
conclude that a default invocation runs the Plan it just printed.

Every invocation executes the packaged prompt command document. A second root is
executed only under `--run`. The diagram now shows the four deliveries as the
alternatives they are, and the paragraph after it puts the complete scope
boundary where it belongs: before that optional second execution, so whatever
result follows has already let go of the conversation that wrote it.

The same claim opened the prompt profile's header and the Tier PR introduction,
and three nearby comments still had execution as the thing that follows approval
rather than one of the things that might. Corrected to the approved bytes, or to
the run that may follow.

Prose only: no runtime behaviour, no tests and no evidence changed.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6
@taras
taras force-pushed the agent/issue-260-prompt-command branch from edb680f to 142272d Compare August 30, 2026 22:32
@taras taras changed the title ✨ Add xmd prompt, the authorship command (#260) ✨ Add xmd prompt: turn a request into an approved executable Plan (#260) Aug 30, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 9 redundant comments. Inline suggestions to remove them below.

Comment thread packages/cli/src/cli.ts
},
// Declared so `xmd run --help` lists it with every other option. The value is
// lifted out of argv by readEvalFlags before parsing — see eval-source.ts —
// so this field is never the source of the document.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// so this field is never the source of the document.

stream: new InMemoryStream(),
// No repository component search. What the document may name is
// what this profile declares, so a file in the caller's tree cannot
// answer for `<CheckDraft>`, `<Prompt>` or anything else.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// answer for `<CheckDraft>`, `<Prompt>` or anything else.

// The command document lives and dies inside that call's scope. Leaving it
// closes the Prompt tasks, the provider and the Elicitation provider, so a
// teardown failure raises out here — before the admission, the output file
// and the run that would otherwise already have happened.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// and the run that would otherwise already have happened.

const source = authored.value;
if (command.output !== undefined) {
// Before the run, so a Plan that fails at run time is still on disk to read
// and hand-edit. An existing path is refused and nothing after it happens.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// and hand-edit. An existing path is refused and nothing after it happens.

// The approved Plan is the result. It goes to stdout exactly as the agent
// wrote it — no fence, no heading, no trailing newline of this command's —
// so a caller can pipe it into a file, a diff or another program. A caller
// who named `--output` already has it, and gets a quiet command instead.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// who named `--output` already has it, and gets a quiet command instead.


// Whether the root declares itself readably. Inspection would raise on a
// malformed declaration, and recovering a code from an exception's prose is
// exactly what the structured answer exists to replace.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// exactly what the structured answer exists to replace.

const dir = join(tmpdir(), `xmd-prompt-${randomUUID()}`);
// A sibling rather than a child: the working directory is what the approved
// document writes into and what several cases read back, and a profile root
// inside it would show up in those listings.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// inside it would show up in those listings.

yield* ensure(() => rm(dir, { recursive: true, force: true }));
// Recursive, and safe because it is: everything under this root was created
// by this scope, so nothing here can reach a directory another case or a
// real invocation owns.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// real invocation owns.

});
// The provider `API.Files` has no host default for, installed exactly where
// the runtime entrypoint installs it: a document that reaches the
// filesystem must reach the caller's, or fail.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// filesystem must reach the caller's, or fail.

taras added 3 commits August 30, 2026 18:50
An option's name is read up to its first `=`, so `--run=false` arrived under the
name of the switch and was taken for it. That set the flag saying execution had
been requested, which is also what satisfies the gate refusing options that only
configure a run — so `--run=false --journal trace.jsonl` was accepted.

What followed is worth stating precisely, because it is not what the flag being
set suggests. The ordinary parser reads `--run=false` as false and `--run=true`
as the field's default, which is also false. So the command took the write path
either way: `--run=false --journal <path>` completed successfully, created no
journal, and never told the caller that the journal they asked for was not a
thing this invocation would produce; and `--run=true` was silently answered by a
command that wrote the Plan instead of running it. One half of the command
believed a run had been requested and the other half did not, and neither told
anybody.

Every valued spelling — `--run=false`, `--run=true`, `--run=` — now fails fixed
preflight, before it establishes that execution was requested and before it can
answer for the gate. Nothing is loaded, built, contacted, opened, created or
executed.

The regressions fail without it: the scanner cases find no error, and the
command-level case exits 0 where it now exits 1 — the old behaviour was a
success, not an incidental failure, which is what makes the tripwires worth
asserting.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6
…260)

The refusal has to prove authorship never began, and the two tripwires that say
so most directly were implied rather than asserted. A provider count of zero
already rules out an established session, but the claim worth making is the
narrow one: no session was established, and no directory was made for one to run
in. The absence of an output file was likewise folded into an empty working
directory rather than stated.

Evidence only: no behaviour, no message and no other case changed.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6
The defect was a disagreement between two layers: the scanner read `--run=false`
as the switch while the parser read it as false. The command-level regression for
it hand-built the scanned result and called `runPrompt` directly, so it never
reached `preparePropsPhase`, the parser or dispatch — the boundary the bug lived
on. It would have passed with the two layers still disagreeing.

A real invocation now covers it, for `--run=false`, `--run=true` and `--run=`:
the exact refusal, empty stdout so no approved Plan escaped, no unavailable-agent
error because reaching a provider is what produces one, and an empty working
directory holding neither the journal it named nor anything else.

Both existing cases stay, because the three prove different things. The scanner
table is the grammar. This one is the boundary. The direct one names the phases
that stayed at zero, which a subprocess cannot see.

Without the refusal this case fails at `Expected: 1 / Received: 0`, and its
stderr carries the unavailable-agent error — the command line reached a provider,
which is the authorship this refusal exists to prevent.

Evidence only: no production change.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 5 redundant comments. Inline suggestions to remove them below.

Comment thread packages/cli/src/cli.ts
},
// Declared so `xmd run --help` lists it with every other option. The value is
// lifted out of argv by readEvalFlags before parsing — see eval-source.ts —
// so this field is never the source of the document.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// so this field is never the source of the document.

Comment thread packages/cli/src/cli.ts
// The same object generation was configured from. The provider it
// installs is a fresh ordinary one — the profile's scope is already
// gone — but which agent it defaults to and what it may do were
// decided once, for the whole invocation.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// decided once, for the whole invocation.

// silence — and a caller who asked for something the command never did
// has not been answered. `--save` is named because it is the one
// spelling somebody may remember; it was replaced before release, so
// there is no alias to keep.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// there is no alias to keep.

// declared it. Frozen while the Plan is being written and carried into the
// final gate, so the bytes that are delivered are checked against the command
// line that was written rather than against whichever draft happened to be
// last.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// last.

Comment thread scripts/build-npm.ts
// emitted module. dnt emits the module graph and nothing else, so an asset no
// TypeScript imports would be absent from the published package while the
// source checkout kept working — the command would find nothing at runtime,
// on Node and Bun only.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// on Node and Bun only.

@taras
taras marked this pull request as ready for review August 30, 2026 23:12
@taras
taras enabled auto-merge (squash) August 30, 2026 23:12
@taras
taras merged commit 3948fe4 into main Aug 30, 2026
30 checks passed
@taras
taras deleted the agent/issue-260-prompt-command branch August 30, 2026 23:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add xmd prompt: turn a request into an approved executable Plan

1 participant